跳到主要内容

TypeScript 类型定义风格指南

· 阅读需 24 分钟
Random Image
图片与正文无关

本指南假设你的 npm 包是原生 ES Module (ESM) 格式。编写和维护良好的 TypeScript 类型定义 (.d.ts 文件) 对于库的开发者和使用者都至关重要。它不仅能提供强大的代码提示、自动补全和编译时类型检查,还能作为一种精确的 API 文档形式,显著提升开发体验 (DX) 和代码健壮性。

核心清单 (Core Checklist)

在为你的 npm 包添加或更新 TypeScript 类型定义时,请遵循以下核心原则:

  1. 代码风格:
    • 使用 Tab 进行缩进。
    • 使用 分号 (;) 结束语句。
    • 补充: 保持一致的代码风格有助于团队协作和代码库的可维护性。选择 Tab 还是空格、是否使用分号虽然有争议,但关键是遵循项目或团队的统一规范。
  2. TypeScript 版本: 类型定义应针对 最新稳定版 的 TypeScript 进行编写。
    • 补充: 利用 TypeScript 的最新特性可以编写出更精确、更简洁的类型定义,同时确保与最新的 TS 开发环境兼容。
  3. 文档注释: 所有导出的属性、方法、类型等都应使用 TSDoc 格式添加文档注释 (详见下文)。
    • 补充: 清晰的文档注释是良好类型定义的关键组成部分,IDE 会利用这些注释提供丰富的悬停提示信息。
  4. 类型测试: 类型定义必须经过测试 (详见下文)。
    • 补充: 类型测试确保你的类型定义能正确反映运行时的行为,防止类型错误或不准确导致的使用者困惑。
  5. Node.js 类型:
    • 如果需要使用 Node.js 内置模块的类型 (如 fs, http 等),请将 @types/node 安装为 开发依赖 (devDependencies)。
    • 不要 在类型定义文件顶部添加 /// <reference types="node"/> 三斜线指令。应使用 ES Module 的 import 语法导入所需类型。
    • 补充: 将 @types/node 作为 devDependency 是因为类型检查只在开发阶段需要。避免三斜线指令有助于保持代码的模块化和清晰性,import 语法是现代 JS/TS 的标准。
  6. 第三方库类型:
    • 如果你的类型定义依赖了其他第三方库的类型 (通常在 @types/* 命名空间下),这些类型包必须作为 直接依赖 (dependencies) 安装。
    • 同样,使用 import 语句导入类型,不要 使用三斜线指令。
    • 补充: 如果你的 .d.ts 文件导出的类型签名中包含了来自 @types/some-lib 的类型,那么使用者在安装你的包时,也需要这个类型信息才能正确进行类型检查。因此,这些类型包是公共 API 的一部分,应作为 dependencies
  7. 避免常见错误: 确保你没有犯 DefinitelyTyped 常见错误列表 中提到的问题。
  8. 默认导出: 对于具有默认导出的包,使用 export default function foo(…)export default class Bar {…} 等标准语法。
    • 补充: 这确保了与 ES Module 的 import defaultMember from 'package' 语法兼容。
  9. 禁止 namespace: 不要使用 namespace (或旧的 module) 关键字来组织类型。
    • 补充: ES Module 提供了标准的模块化机制,namespace 是旧时代的产物,在现代 TS 开发中应避免使用,以防命名冲突和增加理解负担。
  10. 遵循贡献流程 (如果适用,例如向 Sindre Sorhus 的项目贡献):
    • Pull Request 的标题应为 Add TypeScript definition (请直接复制粘贴,确保无误)。
    • 积极帮助审查其他添加类型定义的 Pull Requests

可以参考以下优秀实践案例:

package.json 配置

正确配置 package.json 是让 TypeScript 和开发工具找到你的类型定义的关键。

  1. types 字段:
    • 使用 "types" 字段指定主类型定义文件的路径,不要 使用 "typings"
    • 补充: "types" 是 TypeScript 官方推荐且更现代的字段名。
    • 该字段应放置在所有官方 package.json 属性 (如 name, version, main, module, exports) 之后,但在自定义属性之前。推荐放在 "dependencies" 和/或 "devDependencies" 之后。
  2. 入口文件与类型文件命名:
    • 如果包的入口文件是 index.js (或 index.mjs 等),则类型定义文件应命名为 index.d.ts 并放置在 包的根目录
    • 在这种情况下,你 不需要package.json 中添加 "types" 字段,TypeScript 会根据 mainexports 字段自动推断并找到同名的 .d.ts 文件。
    • 补充: 利用 TypeScript 的默认推断机制可以简化配置。但如果你的类型定义文件不在根目录或名称与入口文件不同,则必须使用 "types" 字段显式指定。
  3. files 字段:
    • 确保将类型定义文件 (如 index.d.tsdist/types/ 目录) 添加到 package.json"files" 数组中。
    • 补充: "files" 字段控制哪些文件会被包含在最终发布的 npm 包里。忘记添加类型定义文件会导致使用者安装了你的包却无法获得类型支持。

类型定义最佳实践 (Type Definition Best Practices)

编写清晰、准确、易于使用的类型是我们的目标。

类型命名与风格

  1. 类型命名:
    • 类型名称不应包含命名空间前缀,例如使用 type Options {} 而不是 type FooOptions {},除非确实存在多个同名 Options 接口需要区分。
    • 避免使用缩写:函数参数、变量和类型名应使用完整单词,如 function process(options: Options) 而不是 function process(opts: Opts)
    • 接口名称 不要I 前缀:使用 Options 而不是 IOptions
    • 补充: 这些约定提高了代码的可读性和一致性。I 前缀是某些语言 (如 C#) 的习惯,但在 TypeScript 社区已不再推荐。
  2. 数组类型:
    • 使用数组类型简写:number[] 而不是 Array<number>
    • 使用 readonly 修饰符表示只读数组:readonly number[] 而不是 ReadonlyArray<number>
    • 补充: 简写形式更简洁,readonly T[] 是 TS 内置的、更推荐的只读数组表示法。
  3. 泛型变量:
    • 当一个方法或类型有多个泛型类型变量时,应使用具有描述性的名称:type Mapper<Element, NewElement> = … 而不是 type Mapper<T, U> = …
    • 补充: 虽然 T, U, K, V 是常见的泛型变量名,但在有多个泛型参数时,描述性名称能显著提高复杂类型的可读性。
  4. 代码格式:
    • 导入、解构赋值和对象字面量中,标识符周围 应有空格:使用 {foo} 而不是 { foo }
    • 补充: 这是许多流行格式化工具 (如 Prettier) 的默认行为,保持一致性。

类型精确性

  1. unknown vs any:
    • 尽可能使用 unknown 类型替代 any
    • 补充: any 会完全绕过类型检查,是类型安全的“后门”。unknown 则表示“任何类型都可以赋给我,但我是类型安全的,你不能直接操作我,必须先进行类型检查或断言”。使用 unknown 能在保持灵活性的同时强制进行类型保护,提高代码的健壮性。
  2. 避免宽泛类型:
    • 不要使用过于宽泛的类型,如 objectFunction。应使用更具体的类型签名,例如 Record<string, number> (表示键为字符串、值为数字的对象) 或 (input: string) => boolean; (表示接受一个字符串参数并返回布尔值的函数)。
    • 补充: object 类型几乎不提供任何有用的类型信息。Function 类型同样无法描述函数的参数和返回值。使用具体的类型签名能提供更强的类型约束和更好的开发体验。
  3. 对象索引签名:
    • 对于接受具有字符串索引类型的对象作为参数时,可以使用 Record<string, any>
    • 对于返回此类对象时,应使用 Record<string, unknown>
    • 原因:TypeScript 对 Record<string, any> 有特殊处理:任何对象类型都可以赋值给它。这使得它在作为函数参数时更具灵活性(尽管损失了一些类型安全)。而返回 Record<string, unknown> 则更安全,强制使用者在使用返回值之前进行类型检查。

强调只读性 (Prefer Read-only Values)

当某个值(通常是函数返回值或配置选项)不应该被修改时,明确地将其标记为只读。熟悉 readonly 关键字在属性数组/元组类型中的应用。此外,还有一个 Readonly<T> 映射类型可以将一个类型的所有属性标记为 readonly

之前:

type Point = {
x: number;
y: number;
children: Point[];
};

之后:

type Point = {
readonly x: number;
readonly y: number;
readonly children: readonly Point[];
};

// 或者对于整个对象及其嵌套属性
type DeepReadonlyPoint = Readonly<{
x: number;
y: number;
children: ReadonlyArray<DeepReadonlyPoint>; // 注意递归应用
}>;

补充: 使用 readonly 有助于实现不可变性 (Immutability),这可以减少副作用,使代码状态更可预测,特别是在处理复杂数据结构或进行函数式编程时。

显式导入类型 (Import Types Explicitly)

除了 JavaScript 内置类型 (如 string, Promise) 或无法通过 import 导入的全局类型 (例如某些环境特定的全局变量),都 不要 依赖隐式的全局类型。

之前: (假设 Electron 类型被全局注入)

export function getWindow(): Electron.BrowserWindow;

之后: (明确导入所需类型)

import { BrowserWindow } from "electron"; // 需要安装 @types/electron

export function getWindow(): BrowserWindow;

补充: 显式导入使代码的依赖关系更清晰,避免了全局命名空间的污染,并且更符合 ES Module 的规范。

可读的命名导入 (Readable Named Imports)

当导入的类型名称可能与其他局部变量或类型冲突,或者名称本身不够清晰时,使用 as 关键字进行重命名,提供一个更具可读性的别名。

之前: (如果 Writable 在当前作用域可能引起歧义)

import { Writable } from "node:stream";

export function createLogStream(): Writable;

之后: (使用别名提高清晰度)

import { Writable as WritableStream } from "node:stream"; // 需要 @types/node

export function createLogStream(): WritableStream;

补充: 好的命名是代码自文档化的重要部分。对于来自 Node.js 或其他库的通用名称 (如 Stream, Buffer, EventEmitter),添加上下文相关的后缀 (如 WritableStream, FileBuffer, MyEventEmitter) 通常是个好主意。

使用 TSDoc 编写文档注释 (Documenting with TSDoc)

所有导出的定义 (函数、类、类型、接口、常量等) 都应使用 TSDoc 标准进行文档注释。你可以从项目的 README.md 文件中借鉴描述性文字。

示例:

/**
* Represents configuration options for the 'add' function.
*/
export type Options = {
/**
* Allow negative numbers in the calculation.
*
* @default true
*/
readonly allowNegative?: boolean;

/**
* Specifies whether the ultimate foo feature is enabled.
*
* Note: Only use this feature for good, never for evil.
*
* @default false
*/
readonly hasFoo?: boolean;

/**
* The directory path where the result should be saved.
*
* Defaults to the user's downloads directory (see [https://example.com](https://example.com) for details).
*
* @example
* ```typescript
* import add from 'my-add-package';
*
* add(1, 2, {saveDirectory: '/my/awesome/dir'});
* ```
*/
readonly saveDirectory?: string;
};

/**
* Adds two numbers together.
* This function serves as a basic example for demonstrating TSDoc usage.
*
* @param x - The first number to add. Must be a finite number.
* @param y - The second number to add. Must be a finite number.
* @param options - Optional configuration for the addition process.
* @returns The sum of `x` and `y`.
*
* @throws {TypeError} If either x or y is not a finite number.
*/
export default function add(x: number, y: number, options?: Options): number;

TSDoc 注释规范:

  • 注释块以 /** 开始,以 */ 结束。
  • 注释内容 不要* 开头。
  • 注释文本 不要 进行硬换行 (hard-wrap),让编辑器或渲染工具自动处理。
  • 在类型/接口的属性文档之间保留一个空行。
  • 句子应以大写字母开头,以句点 (.) 结束。
  • 函数/方法的主描述后面应有一个空行,然后是 @param, @returns 等标签。
  • 所有参数 (@param) 和返回值 (@returns) 都应被文档化。
  • @param 后面跟着参数名,然后是一个破折号 (-),最后是参数描述。
  • @param 不应 包含参数的类型信息 (类型信息由 TypeScript 代码本身提供)。
  • 如果参数描述仅仅是重复参数名 (例如 @param options - The options object),可以省略描述 (除非参数名本身不够清晰)。对于常见的 options 参数,通常不需要额外描述。
  • 如果函数返回 void 或包裹 void 的 Promise (如 Promise<void>),可以省略 @returns 标签。
  • 使用 @returns 而不是 @return
  • @returns 的描述应清晰说明返回值的含义,除非返回值的类型本身已经足够清晰,否则不要简单重复类型信息 (例如,将 @returns A boolean of whether it was enabled. 改进为 @returns Whether it was enabled.)。
  • 使用 @default 标签记录选项或参数的默认值。由于类型定义本身不能指定默认值,这个标签非常重要。如果默认值需要文字描述而不是简单的字面量,可以使用 Default: Some description. 的格式。
  • 如果包含 @example 标签,其上方应有一个空行。代码示例应使用 Markdown 的代码块 (三个反引号 ```) 包裹,并最好指定语言 (如 typescriptjavascript)。
  • 环境声明 (ambient declarations, .d.ts 文件中的内容) 不能有默认参数值。如果函数实现中有默认参数,应在 @param 的描述中或使用 @default 标签进行说明。

补充: 遵循 TSDoc 规范使得文档注释能够被各种工具 (如 VS Code 的 IntelliSense、TypeDoc 等文档生成器) 正确解析和展示,极大地提升了库的使用者体验。

代码示例 (Code Examples in Docs)

  • 尽可能多地包含代码示例,可以从 README.md 或实际用例中提取。
  • 代码示例应该是功能完整的,能够直接运行 (或稍作修改即可运行),并应包含必要的 import 语句。
  • 补充: 好的代码示例是学习如何使用库的最快方式。确保示例清晰、准确且涵盖常见用例。

使用 tsd 进行类型测试 (Testing Types with tsd)

类型定义文件 (.d.ts) 本身也需要测试!这确保了类型定义的准确性,能捕获诸如类型过于宽泛、过窄、错误或与实际 JavaScript 实现不符等问题。推荐使用 tsd 这个工具来进行类型测试。

集成示例: 查看 sindresorhus/filled-array 的这个提交,了解如何将 tsd 集成到项目中 (通常是在 package.jsonscripts 中添加一个测试命令,并创建一个类型测试文件)。

测试文件示例:

// test/index.test-d.ts 或类似路径
import { expectType, expectError, expectAssignable } from "tsd";
import delay, { type Options } from "../index.js"; // 假设 index.js 是入口

// 测试成功案例 (类型匹配)
expectType<Promise<void>>(delay(200));
expectType<Promise<string>>(delay(200, { value: "🦄" }));
expectType<Promise<number>>(delay(200, { value: 0 }));

// 测试 Promise<never> for reject
expectType<Promise<never>>(delay.reject(200, { value: "🦄" }));
expectType<Promise<never>>(delay.reject(200, { value: 0 }));

// 测试函数的可赋值性 (参数类型检查)
const options: Options = { value: "test" };
expectAssignable<Options>(options);
// expectAssignable<Options>({unknownProp: 1}); // 这会报错,因为 unknownProp 不在 Options 类型中

// 测试错误案例 (类型不匹配或用法错误)
expectError(delay("200")); // 参数类型错误
expectError(delay(100, { value: true })); // value 类型错误 (假设它只接受 string/number)

// 测试只读属性
const resultPromise = delay(100, { value: "hello" });
expectType<Promise<string>>(resultPromise);
// resultPromise.value = 'world'; // 如果 Promise<string> 本身没有特殊处理,这行会报错
// 对于返回的对象,测试其属性是否只读
// const resultObj = await someFuncReturningReadonlyObject();
// expectError(resultObj.someProp = 'new value'); // 如果 someProp 是 readonly

类型测试注意事项:

  • 类型测试文件通常命名为 *.test-d.ts (例如 index.test-d.ts),放在 testtests 目录下。

  • tsd 支持在测试文件中使用顶层 await (Top-Level Await)。

  • 重要: 在测试返回 Promise 的函数时,不要expectType 中使用 await 关键字。应直接断言返回值的类型是 Promise<ExpectedType>,如 expectType<Promise<string>>(func())

    • 原因: 如果你写 expectType<string>(await func()),即使 func 意外地返回了一个非 Promise 的值 stringawait 也会正常工作 (它会直接返回值),导致测试通过,但类型定义 (声明返回 Promise<string>) 实际上是错误的。直接测试 Promise<ExpectedType> 能确保函数确实返回了一个 Promise。
  • 当需要向被测试函数传递字面量类型或只读类型的值时,可以使用 const 断言 (as const)。这会告诉 TypeScript 将类型推断为最窄的字面量类型或只读类型。

    import myLib from "./my-lib";
    import { expectType } from "tsd";

    // 假设 myLib.process 需要一个字面量类型 'literal' 或 'const'
    const config = { type: "literal" } as const; // 'as const' 使 type 成为 'literal' 类型
    expectType<void>(myLib.process(config));

    const readonlyArray = ["a", "b"] as const; // 推断为 readonly ['a', 'b']
    expectType<readonly string[]>(myLib.handleArray(readonlyArray));
  • 适当时,使用 expectError() 来验证不正确的用法确实会导致 TypeScript 编译错误。这有助于确保你的类型定义不会过于宽松。

  • 使用 expectAssignable<Target>(source) 来测试 source 类型的值是否可以赋值给 Target 类型的变量,这对于检查接口兼容性很有用。

补充: tsd 测试是类型定义的“单元测试”。它不运行实际代码,而是利用 TypeScript 编译器来检查类型声明是否符合预期。这对于维护高质量、可靠的类型定义至关重要,尤其是在库代码或类型定义发生变更时。

总结

为 npm 包提供高质量的 TypeScript 类型定义是现代前端和 Node.js 开发中的一项重要实践。遵循本指南中的约定和最佳实践,可以帮助你创建出清晰、准确、易于使用且健壮的类型定义,从而:

  • 提升开发者体验 (DX):提供精准的自动补全和类型提示。
  • 增强代码健壮性: 在编译时捕获潜在的类型错误。
  • 改善可维护性: 类型定义本身就是一种精确的 API 文档。
  • 促进库的采用: 良好的类型支持是许多开发者选择库的重要因素。

投入时间编写和维护高质量的 .d.ts 文件,是对你的库用户最好的投资之一。